Better than IO, part 2

intro

In previous part we have discussed mtl approach with stack of transformers. Lets see how can we improve on that.

part 1 drawbacks

As you remember from previous part we used to say that Log, Db and Net are monad transformers. And then we used MTL style approach to access parts of that transformets stack. E.g. MonadLog, MonadDb and MonadNet. While that can work, we also learned that it's also generating a lot of boilerplate for each of effect type we want to have. Mostly because we have to define that giant AppStack stack of monad transformers. This is cumbersome - for any effect we should define an extra monad transformer. So in real world application we most likely will end-up with 10 monad transformers stacked together. And you have to define map/pure/flatMap for all of them. And also define how to access them - either using some automatic dereviation or manually.

Performance wise it also does not come for free - each time we will need to wrap and unwrap our value into 10 layers of objects. That will introduce runtime performance penalty.

optimizing that AppStack

So far we have huge AppStack for every effect that we are handling in our program. And we also have this "accessors" to parts of that stack. E.g. LogT has corresponding MonadLog. And that MonadLog instance for AppStack knows how to produce LogT effects in that AppStack. What if we simplify that AppStack and give more power to that accessors typeclass instances? Logging does not need it's own LogT, all the logging can be easily done in the IO. We can reduce our AppStack to be simply IO. And then define MonadLog, MonadDb and MonadNet instances for IO. Lets see how it will work out.

subset of side effects, again

So we want to declare our function to produce subset of side effects. We are improving our

def foo(x:Int):IO[Int]

That actually does access to database and logging.

How about we define accessors to db and logger and just do dependency injection?

def foo(x:Int)(implicit logger:Logger, db:Db):IO[Int]

Better, we can see that function is using logger and db. So most likely it will not produce any other side effect. But it can absolutely can access some singleton and do some filesystem operations from there because we got that IO[Int] at the end.

Lets get rid of it - we can simply request any monad and combine actions in it.

def foo[F[_]:Monad](x:Int)(implicit logger:Logger, db:Db):F[Int]

Our logger has method def log(msg:String):IO[Unit] and Db also has methods in IO. We can't use them in our function that uses F[_]. Lets go further and declare interface for logger and db - LoggerAlg and DbAlg. Where Alg is abbreviation for Algebra.

trait LoggerAlg[F[_]] {
  def log(msg:String):F[Unit]
}

trait Db[F[_]] {
  def getUser(userId:UserId):F[User]
}

def foo[F[_]:Monad](implicit loggerAlg:LoggerAlg[F], db:DbAlg[F]):F[Int]

Now we can implement logic of our method foo. But we can't run it. To run it we should provide typeclass instance for DbAlg[IO] and LoggerAlg[IO]. Then we can run it. For example:

class IOLoggerAlg(logger:Logger) extends LoggerAlg[IO] {
  override def log(msg:String):IO[Unit] = { logger.log(msg) }
}

object IOLoggerAlg {
  def impl(logger:Logger) = new IOLoggerAlg(logger)
}

class IODbAlg(db:Db) extends DbAlg[IO] {
  def getUser(userId):IO[User] = {
    db.getUsre(userId)
  }
}

object IODbAlg {
    def impl(db:Db) = new IODbAlg(db)
}

implicit val loggerAlg = LoggerAlg(logger)
implicit val dbAlg = IODbAlg(db)
foo[IO](12)

Now you wonder will it combine with our function bar, that as you remember from part 1 was doing Network operations and logging. We apply same principles and get

def bar[F[_]:Monad:LoggerAlg:NetAlg](x:Int):F[Int]

Both bar and foo produce F[_] which is Monad - so no problems combining them. In case if we "run" them - specializing them on the same monad - also no problems composing.

foo[IO](12) >>= bar[IO]

This approach called tagless final. For now no explanation why, we have to understand few more things to understand the name. But we can perfectly use the approach without understanding it's name.

wtf is algebra

Algebra in this case is just set of objects and operations. It's just a way to describe your computation. In other words it's embedded dsl.

testing

Testing is straightforward. You don't have to use IO for testing, Id will be just fine. So for testing our foo method all we need is mock algebras in Id monad.

implicit val loggerAlgMock:LoggerAlg[Id] = new LoggerAlg[Id] { ... }
implicit val dbAlgMock:DbAlg[Id] = new DbAlg[Id] { ... }
foo[Id](12) should ===(42)

summoners

While writing code for foo we will have 2 possibilities

def foo[F[_]:Monad:LoggerAlg:DbAlg](x:Int):F[Int] = for {
  _ <- implicitly[LoggerAlg[F]].log("hello world")
  user <- implicitly[DbAlg[F]].findUser(UserId("my-user-id"))
} yield user.age

and

def foo[F[_]:Monad:LoggerAlg:DbAlg](x:Int)(implicit loggerAlg:LoggerAlg[F], dbAlg:DbAlg[F]):F[Int] = for {
  _ <- loggerAlg.log("hello world")
  user <- dbAlg.findUser(UserId("my-user-id"))
} yield user.age

Can we do better? Lets take approach #3 - summoners - we will define specialized implicitly for every algebra.

object LoggerAlg {
  def apply[F[_]](implicit loggerAlg:LoggerAlg[F]) = loggerAlg
}

object DbAlg {
  def apply[F[_]](implicit dbAlg:DbAlg[F]) = dbAlg
}

def foo[F[_]:Monad:LoggerAlg:DbAlg](x:Int):F[Int] = for {
  _ <- LoggerAlg[F].log("hello world")
  user <- DbAlg[F].findUser(UserId("my-user-id"))
} yield user.age

using with monad transformers

Monad transformers still can be highly useful, especially for cases when we are handling errors. Lets see how can we do it on example of DbAlg that returns Either.

trait DbAlg[F[_]] {
    def updateUserAge(userId:UserId, newAge:Int):F[Either[UserNotFound, User]]
}

def foo[F[_](x:Int):Monad:LoggerAlg:DbAlg]:F[Either[UserNotFound, Int]] = {
    for {
      _ <- EitherT.liftF(LoggerAlg[F].log("hello world"))
      u <- EitherT(DbAlg[F].updateUserAge(UserId("my-user-id"), x))
    } yield 
}.value

That lifting of LoggerAlg is annoying. We can make it slightly better with natural transformations.

def foo[F[_](x:Int):Monad:LoggerAlg:DbAlg]:F[Either[UserNotFound, Int]] = {
    for {
      _ <- LoggerAlg[F].log("hello world").nat[EitherT[F, String, ?]]
      u <- EitherT(DbAlg[F].updateUserAge(UserId("my-user-id"), x))
    } yield 
}.value

We still have to specify EitherT type parameters. But this one should work way better for optional.

See github:better-than-io-tf for details.

eco-system

Tagless final approach plays nicely with doobie, fs2 and http4s.

part 3

In part 3 we are going to answer second question - "must produce side effect".

source code

You can find source code at github:better-than-io-tf